Coves frontend - a photon fork
at main 180 lines 6.6 kB view raw
1import type { RequestHandler } from './$types' 2import { DEFAULT_INSTANCE_URL } from '$lib/app/instance.svelte' 3import { validateProxyPath } from '../validate' 4 5/** 6 * ============================================================================= 7 * API PROXY SECURITY MODEL 8 * ============================================================================= 9 * 10 * PURPOSE: 11 * This proxy exists to keep authentication tokens secure by never exposing them 12 * to the browser. Authentication is managed via a backend-delegated session: the 13 * Coves Go backend sets a sealed (encrypted) session cookie during OAuth, and 14 * the SvelteKit frontend forwards that cookie to the backend's /api/me endpoint 15 * for validation. The proxy injects the Authorization header (using the sealed 16 * token from the cookie) on behalf of the client, so the client never needs to 17 * handle or store tokens. 18 * 19 * TRUST MODEL: 20 * - Client -> Proxy: Client is untrusted. All paths are validated for security 21 * issues (traversal, injection, etc.). The proxy only forwards to the 22 * pre-configured backend instance URL from the user's session. 23 * - Proxy -> Backend: Backend is trusted. The proxy forwards requests with 24 * auth headers to the Coves server at the user's registered instance URL. 25 * 26 * PATH VALIDATION: 27 * The path is validated to prevent: 28 * - Path traversal attacks (../ patterns) 29 * - Null byte injection (can truncate paths) 30 * - Protocol injection (javascript:, data:, etc.) 31 * - Encoded path separators that could bypass validation 32 * 33 * HEADER HANDLING: 34 * Stripped from request: 35 * - 'host': Prevents host header attacks; backend should see its own host 36 * - 'connection': Hop-by-hop header, not meant to be forwarded 37 * 38 * Added to request: 39 * - 'Authorization': Bearer token from encrypted session (if authenticated) 40 * 41 * Stripped from response: 42 * - 'content-encoding': Let SvelteKit handle compression; avoids double-encoding 43 * 44 * ============================================================================= 45 */ 46 47/** 48 * Handles proxying requests to the upstream Coves server. 49 * Injects the Authorization header from the session if available. 50 */ 51async function handler({ 52 params, 53 request, 54 locals, 55 fetch: fetchFn, 56}: { 57 params: { path: string } 58 request: Request 59 locals: App.Locals 60 fetch: typeof fetch 61}): Promise<Response> { 62 const path = params.path 63 64 // Validate path for security issues 65 const pathError = validateProxyPath(path) 66 if (pathError) { 67 return new Response( 68 JSON.stringify({ error: 'Bad Request', message: pathError }), 69 { 70 status: 400, 71 headers: { 'Content-Type': 'application/json' }, 72 }, 73 ) 74 } 75 76 // Determine target instance (from session or default) 77 // Instance may already include protocol (e.g., "https://coves.social") or be just the hostname 78 const instance = locals.auth.authenticated 79 ? locals.auth.account.instance 80 : DEFAULT_INSTANCE_URL 81 let baseUrl: string 82 if (instance.startsWith('http://') || instance.startsWith('https://')) { 83 // Instance already has protocol, use as-is 84 baseUrl = instance 85 } else { 86 // Instance is just hostname, add https:// 87 baseUrl = `https://${instance}` 88 } 89 90 // In production, only allow HTTPS URLs to prevent MITM attacks 91 if (import.meta.env.PROD && baseUrl.startsWith('http://')) { 92 return new Response( 93 JSON.stringify({ 94 error: 'Bad Request', 95 message: 'HTTP URLs are not allowed in production', 96 }), 97 { 98 status: 400, 99 headers: { 'Content-Type': 'application/json' }, 100 }, 101 ) 102 } 103 // Remove trailing slash from baseUrl if present to avoid double slashes 104 // Preserve query parameters from the original request 105 const requestUrl = new URL(request.url) 106 const queryString = requestUrl.search 107 const targetUrl = `${baseUrl.replace(/\/$/, '')}/${path}${queryString}` 108 109 // Build headers for upstream request 110 const headers = new Headers(request.headers) 111 112 // Strip hop-by-hop and security-sensitive headers 113 // 'host' - Prevents host header attacks; backend should receive its own host 114 // 'connection' - Hop-by-hop header, not meant to be forwarded through proxies 115 headers.delete('host') 116 headers.delete('connection') 117 118 // Inject Authorization header from the sealed session cookie. 119 // The sealed token is opaque to the browser (encrypted by the Go backend), 120 // so raw access/refresh tokens are never exposed to client-side code. 121 if (locals.auth.authenticated) { 122 headers.set('Authorization', `Bearer ${locals.auth.authToken}`) 123 } 124 125 try { 126 // Forward request 127 const fetchOptions: RequestInit = { 128 method: request.method, 129 headers, 130 } 131 132 // Only include body for methods that support it. 133 // We consume the body as a Blob rather than streaming request.body 134 // (ReadableStream) because Node.js undici has issues with ReadableStream 135 // bodies in fetch(), causing "expected non-null body source" errors. 136 // Using Blob handles both text and binary content types correctly. 137 if (request.method !== 'GET' && request.method !== 'HEAD') { 138 fetchOptions.body = await request.blob() 139 } 140 141 const response = await fetchFn(targetUrl, fetchOptions) 142 143 // Return response, stripping headers that SvelteKit should handle 144 const responseHeaders = new Headers(response.headers) 145 // 'content-encoding' - Let SvelteKit handle compression to avoid double-encoding 146 responseHeaders.delete('content-encoding') 147 148 return new Response(response.body, { 149 status: response.status, 150 headers: responseHeaders, 151 }) 152 } catch (error) { 153 // Generate a unique request ID for error correlation 154 const requestId = crypto.randomUUID().slice(0, 8) // Short ID for easier reference 155 156 // Connection error to upstream - include request context for debugging 157 console.error( 158 `Proxy error [${request.method} /${path}] [requestId: ${requestId}]:`, 159 error, 160 ) 161 return new Response( 162 JSON.stringify({ 163 error: 'Bad Gateway', 164 message: 'Failed to connect to upstream server', 165 requestId, 166 }), 167 { 168 status: 502, 169 headers: { 'Content-Type': 'application/json' }, 170 }, 171 ) 172 } 173} 174 175// Handle all HTTP methods by wrapping the handler 176export const GET: RequestHandler = (event) => handler(event) 177export const POST: RequestHandler = (event) => handler(event) 178export const PUT: RequestHandler = (event) => handler(event) 179export const DELETE: RequestHandler = (event) => handler(event) 180export const PATCH: RequestHandler = (event) => handler(event)